CoreDataStack.swift 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import OSLog
  5. class CoreDataStack: ObservableObject {
  6. static let shared = CoreDataStack()
  7. static let identifier = "CoreDataStack"
  8. private var notificationToken: NSObjectProtocol?
  9. private let inMemory: Bool
  10. let persistentContainer: NSPersistentContainer
  11. private let maxRetries = 3
  12. private let initializationCoordinator = CoreDataInitializationCoordinator()
  13. /// Emits the set of changed object IDs from each batch of persistent history transactions.
  14. /// Sourced from persistent history, so — unlike `NSManagedObjectContextDidSave` — it also
  15. /// covers `NSBatchInsertRequest`/`NSBatchDeleteRequest` and cross-process changes. App-side
  16. /// observers subscribe via `entityChangePublisher` and filter with `filteredByEntityName(_:)`.
  17. private let entityChangeSubject = PassthroughSubject<Set<NSManagedObjectID>, Never>()
  18. var entityChangePublisher: AnyPublisher<Set<NSManagedObjectID>, Never> {
  19. entityChangeSubject.eraseToAnyPublisher()
  20. }
  21. private init(inMemory: Bool = false) {
  22. self.inMemory = inMemory
  23. // Initialize persistent container immediately
  24. persistentContainer = NSPersistentContainer(
  25. name: "TrioCoreDataPersistentContainer",
  26. managedObjectModel: Self.managedObjectModel
  27. )
  28. guard let description = persistentContainer.persistentStoreDescriptions.first else {
  29. fatalError("Failed \(DebuggingIdentifiers.failed) to retrieve a persistent store description")
  30. }
  31. if inMemory {
  32. description.url = URL(fileURLWithPath: "/dev/null")
  33. }
  34. // Enable persistent store remote change notifications
  35. /// - Tag: persistentStoreRemoteChange
  36. description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
  37. // Enable persistent history tracking
  38. /// - Tag: persistentHistoryTracking
  39. description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
  40. // Enable lightweight migration
  41. /// - Tag: lightweightMigration
  42. description.shouldMigrateStoreAutomatically = true
  43. description.shouldInferMappingModelAutomatically = true
  44. persistentContainer.viewContext.automaticallyMergesChangesFromParent = false
  45. persistentContainer.viewContext.name = "viewContext"
  46. /// - Tag: viewContextmergePolicy
  47. persistentContainer.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  48. persistentContainer.viewContext.undoManager = nil
  49. persistentContainer.viewContext.shouldDeleteInaccessibleFaults = true
  50. }
  51. deinit {
  52. if let observer = notificationToken {
  53. Foundation.NotificationCenter.default.removeObserver(observer)
  54. }
  55. }
  56. /// A persistent history token used for fetching transactions from the store
  57. /// Save the last token to User defaults
  58. private var lastToken: NSPersistentHistoryToken? {
  59. get {
  60. UserDefaults.standard.lastHistoryToken
  61. }
  62. set {
  63. UserDefaults.standard.lastHistoryToken = newValue
  64. }
  65. }
  66. // Factory method for tests
  67. static func createForTests() async throws -> CoreDataStack {
  68. let stack = CoreDataStack(inMemory: true)
  69. try await stack.initializeStack()
  70. return stack
  71. }
  72. // Used for Canvas Preview
  73. static func preview() async throws -> CoreDataStack {
  74. let stack = CoreDataStack(inMemory: true)
  75. try await stack.initializeStack()
  76. return stack
  77. }
  78. // Shared managed object model
  79. static var managedObjectModel: NSManagedObjectModel = {
  80. let bundle = Bundle(for: CoreDataStack.self)
  81. guard let url = bundle.url(forResource: "TrioCoreDataPersistentContainer", withExtension: "momd") else {
  82. fatalError("Failed \(DebuggingIdentifiers.failed) to locate momd file")
  83. }
  84. guard let model = NSManagedObjectModel(contentsOf: url) else {
  85. fatalError("Failed \(DebuggingIdentifiers.failed) to load momd file")
  86. }
  87. return model
  88. }()
  89. /// Creates and configures a private queue context
  90. func newTaskContext() -> NSManagedObjectContext {
  91. // Create a private queue context
  92. /// - Tag: newBackgroundContext
  93. let taskContext = persistentContainer.newBackgroundContext()
  94. taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
  95. taskContext.undoManager = nil
  96. return taskContext
  97. }
  98. func fetchPersistentHistory() async {
  99. do {
  100. try await fetchPersistentHistoryTransactionsAndChanges()
  101. } catch {
  102. debug(.coreData, "\(error)")
  103. }
  104. }
  105. private func fetchPersistentHistoryTransactionsAndChanges() async throws {
  106. let taskContext = newTaskContext()
  107. taskContext.name = "persistentHistoryContext"
  108. // debug(.coreData,"Start fetching persistent history changes from the store ... \(DebuggingIdentifiers.inProgress)")
  109. try await taskContext.perform {
  110. // Execute the persistent history change since the last transaction
  111. /// - Tag: fetchHistory
  112. let changeRequest = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastToken)
  113. let historyResult = try taskContext.execute(changeRequest) as? NSPersistentHistoryResult
  114. if let history = historyResult?.result as? [NSPersistentHistoryTransaction], !history.isEmpty {
  115. self.mergePersistentHistoryChanges(from: history)
  116. return
  117. }
  118. }
  119. }
  120. private func mergePersistentHistoryChanges(from history: [NSPersistentHistoryTransaction]) {
  121. // debug(.coreData,"Received \(history.count) persistent history transactions")
  122. // Update view context with objectIDs from history change request
  123. /// - Tag: mergeChanges
  124. let viewContext = persistentContainer.viewContext
  125. viewContext.perform {
  126. for transaction in history {
  127. viewContext.mergeChanges(fromContextDidSave: transaction.objectIDNotification())
  128. self.lastToken = transaction.token
  129. }
  130. }
  131. // Notify app-side observers (services) about which objects changed. This history-sourced
  132. // change feed replaces the hand-rolled changedObjectsOnManagedObjectContextDidSavePublisher.
  133. let changedObjectIDs = Set(history.flatMap { $0.changes ?? [] }.map(\.changedObjectID))
  134. if !changedObjectIDs.isEmpty {
  135. entityChangeSubject.send(changedObjectIDs)
  136. }
  137. }
  138. // Clean old Persistent History
  139. /// - Tag: clearHistory
  140. func cleanupPersistentHistoryTokens(before date: Date) async {
  141. let taskContext = newTaskContext()
  142. taskContext.name = "cleanPersistentHistoryTokensContext"
  143. await taskContext.perform {
  144. let deleteHistoryTokensRequest = NSPersistentHistoryChangeRequest.deleteHistory(before: date)
  145. do {
  146. try taskContext.execute(deleteHistoryTokensRequest)
  147. debug(.coreData, "\(DebuggingIdentifiers.succeeded) Successfully deleted persistent history from before \(date)")
  148. } catch {
  149. debug(
  150. .coreData,
  151. "\(DebuggingIdentifiers.failed) Failed to delete persistent history from before \(date): \(error)"
  152. )
  153. }
  154. }
  155. }
  156. private func setupPersistentStoreChangeNotifications() {
  157. // Observe Core Data remote change notifications on the queue where the changes were made
  158. notificationToken = Foundation.NotificationCenter.default.addObserver(
  159. forName: .NSPersistentStoreRemoteChange,
  160. object: nil,
  161. queue: nil
  162. ) { _ in
  163. Task {
  164. await self.fetchPersistentHistory()
  165. }
  166. }
  167. debug(.coreData, "Set up persistent store change notifications")
  168. }
  169. /// Loads the persistent stores asynchronously.
  170. ///
  171. /// Converts the synchronous NSPersistentContainer loading process into an async/await compatible
  172. /// function using a continuation.
  173. ///
  174. /// - Throws: Any errors encountered during the loading of persistent stores.
  175. /// - Returns: Void once stores are loaded successfully
  176. private func loadPersistentStores() async throws {
  177. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  178. persistentContainer.loadPersistentStores { storeDescription, error in
  179. if let error = error {
  180. warning(.coreData, "Failed to load persistent stores: \(error)")
  181. continuation.resume(throwing: error)
  182. } else {
  183. debug(.coreData, "Successfully loaded persistent store: \(storeDescription.url?.absoluteString ?? "unknown")")
  184. continuation.resume(returning: ())
  185. }
  186. }
  187. }
  188. }
  189. /// Public entry point for initializing the CoreData stack.
  190. ///
  191. /// Uses the initialization coordinator to ensure initialization happens only once,
  192. /// even with concurrent calls. Subsequent calls will wait for the original initialization
  193. /// to complete.
  194. ///
  195. /// - Throws: Any errors that occur during initialization.
  196. /// - Returns: Void once initialization is complete.
  197. func initializeStack() async throws {
  198. try await initializationCoordinator.ensureInitialized {
  199. try await self.initializeStack(retryCount: 0)
  200. }
  201. }
  202. /// Private implementation of the initialization process with retry capability.
  203. ///
  204. /// Handles the actual initialization work including store loading, verification,
  205. /// notification setup, and error handling with retry logic.
  206. ///
  207. /// - Parameter retryCount: The current retry attempt number, starting at 0.
  208. /// - Throws: CoreDataError or any other error if initialization fails after all retries.
  209. /// - Returns: Void when initialization completes successfully.
  210. private func initializeStack(retryCount: Int) async throws {
  211. do {
  212. // Load stores asynchronously
  213. try await loadPersistentStores()
  214. // Verify the store is loaded
  215. guard persistentContainer.persistentStoreCoordinator.persistentStores.isEmpty == false else {
  216. let error = CoreDataError.storeNotInitializedError(function: #function, file: #file)
  217. throw error
  218. }
  219. setupPersistentStoreChangeNotifications()
  220. debug(.coreData, "Core Data stack initialized successfully")
  221. } catch {
  222. debug(.coreData, "Failed to initialize Core Data stack: \(error)")
  223. // If we still have retries left, try again after a delay
  224. if retryCount < maxRetries {
  225. debug(.coreData, "Retrying initialization (\(retryCount + 1)/\(maxRetries))")
  226. // Wait before retrying
  227. try await Task.sleep(for: .seconds(1))
  228. // Retry the initialization
  229. try await initializeStack(retryCount: retryCount + 1)
  230. } else {
  231. // We've exhausted our retries
  232. debug(.coreData, "Core Data initialization failed after \(maxRetries) attempts")
  233. throw error
  234. }
  235. }
  236. }
  237. }
  238. // MARK: - Delete
  239. extension CoreDataStack {
  240. /// Synchronously delete entry with specified object IDs
  241. /// - Tag: synchronousDelete
  242. func deleteObject(identifiedBy objectID: NSManagedObjectID) async {
  243. let viewContext = persistentContainer.viewContext
  244. debug(.coreData, "Start deleting data from the store ...\(DebuggingIdentifiers.inProgress)")
  245. await viewContext.perform {
  246. do {
  247. let entryToDelete = viewContext.object(with: objectID)
  248. viewContext.delete(entryToDelete)
  249. guard viewContext.hasChanges else { return }
  250. try viewContext.save()
  251. debug(.coreData, "Successfully deleted data. \(DebuggingIdentifiers.succeeded)")
  252. } catch {
  253. debug(.coreData, "Failed to delete data: \(error)")
  254. }
  255. }
  256. }
  257. /// Asynchronously deletes records for entities
  258. /// - Tag: batchDelete
  259. func batchDeleteOlderThan<T: NSManagedObject>(
  260. _ objectType: T.Type,
  261. dateKey: String,
  262. days: Int,
  263. isPresetKey: String? = nil,
  264. callingFunction: String = #function,
  265. callingClass: String = #fileID
  266. ) async throws {
  267. let taskContext = newTaskContext()
  268. taskContext.name = "deleteContext"
  269. taskContext.transactionAuthor = "batchDelete"
  270. // Get the number of days we want to keep the data
  271. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  272. // Fetch all the objects that are older than the specified days
  273. let fetchRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: objectType))
  274. // Construct the predicate
  275. var predicates: [NSPredicate] = [NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)]
  276. if let isPresetKey = isPresetKey {
  277. predicates.append(NSPredicate(format: "%K == NO", isPresetKey))
  278. }
  279. fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
  280. fetchRequest.resultType = .managedObjectIDResultType
  281. do {
  282. // Execute the Fetch Request
  283. let objectIDs = try await taskContext.perform {
  284. try taskContext.fetch(fetchRequest)
  285. }
  286. // Guard check if there are NSManagedObjects older than the specified days
  287. guard !objectIDs.isEmpty else {
  288. // debug(.coreData,"No objects found older than \(days) days.")
  289. return
  290. }
  291. // Execute the Batch Delete
  292. try await taskContext.perform {
  293. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: objectIDs)
  294. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  295. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  296. let success = batchDeleteResult.result as? Bool, success
  297. else {
  298. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  299. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  300. }
  301. }
  302. debug(.coreData, "Successfully deleted data older than \(days) days. \(DebuggingIdentifiers.succeeded)")
  303. } catch {
  304. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  305. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  306. }
  307. }
  308. func batchDeleteOlderThan<Parent: NSManagedObject, Child: NSManagedObject>(
  309. parentType: Parent.Type,
  310. childType: Child.Type,
  311. dateKey: String,
  312. days: Int,
  313. relationshipKey: String, // The key of the Child Entity that links to the parent Entity
  314. callingFunction: String = #function,
  315. callingClass: String = #fileID
  316. ) async throws {
  317. let taskContext = newTaskContext()
  318. taskContext.name = "deleteContext"
  319. taskContext.transactionAuthor = "batchDelete"
  320. // Get the target date
  321. let targetDate = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
  322. // Fetch Parent objects older than the target date
  323. let fetchParentRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: parentType))
  324. fetchParentRequest.predicate = NSPredicate(format: "%K < %@", dateKey, targetDate as NSDate)
  325. fetchParentRequest.resultType = .managedObjectIDResultType
  326. do {
  327. let parentObjectIDs = try await taskContext.perform {
  328. try taskContext.fetch(fetchParentRequest)
  329. }
  330. guard !parentObjectIDs.isEmpty else {
  331. // debug(.coreData,"No \(parentType) objects found older than \(days) days.")
  332. return
  333. }
  334. // Fetch Child objects related to the fetched Parent objects
  335. let fetchChildRequest = NSFetchRequest<NSManagedObjectID>(entityName: String(describing: childType))
  336. fetchChildRequest.predicate = NSPredicate(format: "ANY %K IN %@", relationshipKey, parentObjectIDs)
  337. fetchChildRequest.resultType = .managedObjectIDResultType
  338. let childObjectIDs = try await taskContext.perform {
  339. try taskContext.fetch(fetchChildRequest)
  340. }
  341. guard !childObjectIDs.isEmpty else {
  342. // debug(.coreData,"No \(childType) objects found related to \(parentType) objects older than \(days) days.")
  343. return
  344. }
  345. // Execute the batch delete for Child objects
  346. try await taskContext.perform {
  347. let batchDeleteRequest = NSBatchDeleteRequest(objectIDs: childObjectIDs)
  348. guard let fetchResult = try? taskContext.execute(batchDeleteRequest),
  349. let batchDeleteResult = fetchResult as? NSBatchDeleteResult,
  350. let success = batchDeleteResult.result as? Bool, success
  351. else {
  352. debug(.coreData, "Failed to execute batch delete request \(DebuggingIdentifiers.failed)")
  353. throw CoreDataError.batchDeleteError(function: callingFunction, file: callingClass)
  354. }
  355. }
  356. debug(
  357. .coreData,
  358. "Successfully deleted \(childType) data related to \(parentType) objects older than \(days) days. \(DebuggingIdentifiers.succeeded)"
  359. )
  360. } catch {
  361. debug(.coreData, "Failed to fetch or delete data: \(error) \(DebuggingIdentifiers.failed)")
  362. throw CoreDataError.unexpectedError(error: error, function: callingFunction, file: callingClass)
  363. }
  364. }
  365. }
  366. // MARK: - Fetch Requests
  367. extension CoreDataStack {
  368. // Fetch in background thread
  369. /// - Tag: backgroundFetch
  370. func fetchEntities<T: NSManagedObject>(
  371. ofType type: T.Type,
  372. onContext context: NSManagedObjectContext,
  373. predicate: NSPredicate,
  374. key: String,
  375. ascending: Bool,
  376. fetchLimit: Int? = nil,
  377. batchSize: Int? = nil,
  378. propertiesToFetch: [String]? = nil,
  379. callingFunction: String = #function,
  380. callingClass: String = #fileID
  381. ) throws -> [Any] {
  382. let request = NSFetchRequest<NSFetchRequestResult>(entityName: String(describing: type))
  383. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  384. request.predicate = predicate
  385. if let limit = fetchLimit {
  386. request.fetchLimit = limit
  387. }
  388. if let batchSize = batchSize {
  389. request.fetchBatchSize = batchSize
  390. }
  391. if let propertiesToFetch = propertiesToFetch {
  392. request.propertiesToFetch = propertiesToFetch
  393. request.resultType = .dictionaryResultType
  394. } else {
  395. request.resultType = .managedObjectResultType
  396. }
  397. /// we need to ensure that the fetch immediately returns a value as long as the whole app does not use the async await pattern, otherwise we could perform this asynchronously with backgroundContext.perform and not block the thread
  398. return try context.performAndWait {
  399. do {
  400. if propertiesToFetch != nil {
  401. return try context.fetch(request) as? [[String: Any]] ?? []
  402. } else {
  403. return try context.fetch(request) as? [T] ?? []
  404. }
  405. } catch let error as NSError {
  406. throw CoreDataError.fetchError(
  407. function: callingFunction,
  408. file: callingClass
  409. )
  410. }
  411. }
  412. }
  413. // Fetch Async
  414. func fetchEntitiesAsync<T: NSManagedObject>(
  415. ofType type: T.Type,
  416. onContext context: NSManagedObjectContext,
  417. predicate: NSPredicate,
  418. key: String,
  419. ascending: Bool,
  420. fetchLimit: Int? = nil,
  421. batchSize: Int? = nil,
  422. propertiesToFetch: [String]? = nil,
  423. relationshipKeyPathsForPrefetching: [String]? = nil,
  424. callingFunction: String = #function,
  425. callingClass: String = #fileID
  426. ) async throws -> Any {
  427. let request: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: String(describing: type))
  428. request.sortDescriptors = [NSSortDescriptor(key: key, ascending: ascending)]
  429. request.predicate = predicate
  430. if let limit = fetchLimit {
  431. request.fetchLimit = limit
  432. }
  433. if let batchSize = batchSize {
  434. request.fetchBatchSize = batchSize
  435. }
  436. if let propertiesToFetch = propertiesToFetch {
  437. request.propertiesToFetch = propertiesToFetch
  438. request.resultType = .dictionaryResultType
  439. } else {
  440. request.resultType = .managedObjectResultType
  441. }
  442. if let prefetchKeyPaths = relationshipKeyPathsForPrefetching {
  443. request.relationshipKeyPathsForPrefetching = prefetchKeyPaths
  444. }
  445. return try await context.perform {
  446. do {
  447. if propertiesToFetch != nil {
  448. return try context.fetch(request) as? [[String: Any]] ?? []
  449. } else {
  450. return try context.fetch(request) as? [T] ?? []
  451. }
  452. } catch let error as NSError {
  453. throw CoreDataError.unexpectedError(
  454. error: error,
  455. function: callingFunction,
  456. file: callingClass
  457. )
  458. }
  459. }
  460. }
  461. // Get NSManagedObject
  462. func getNSManagedObject<T: NSManagedObject>(
  463. with ids: [NSManagedObjectID],
  464. context: NSManagedObjectContext,
  465. callingFunction: String = #function,
  466. callingClass: String = #fileID
  467. ) async throws -> [T] {
  468. try await context.perform {
  469. var objects = [T]()
  470. do {
  471. for id in ids {
  472. if let object = try context.existingObject(with: id) as? T {
  473. objects.append(object)
  474. }
  475. }
  476. return objects
  477. } catch {
  478. throw CoreDataError.fetchError(
  479. function: callingFunction,
  480. file: callingClass
  481. )
  482. }
  483. }
  484. }
  485. }
  486. // MARK: - Save
  487. /// This function is used when terminating the App to ensure any unsaved changes on the view context made their way to the persistent container
  488. extension CoreDataStack {
  489. func save() {
  490. let context = persistentContainer.viewContext
  491. guard context.hasChanges else { return }
  492. do {
  493. try context.save()
  494. } catch {
  495. debug(.coreData, "Error saving context \(DebuggingIdentifiers.failed): \(error)")
  496. }
  497. }
  498. }
  499. extension NSManagedObjectContext {
  500. // takes a context as a parameter to be executed either on the main thread or on a background thread
  501. /// - Tag: save
  502. func saveContext(
  503. onContext: NSManagedObjectContext,
  504. callingFunction: String = #function,
  505. callingClass: String = #fileID
  506. ) throws {
  507. do {
  508. guard onContext.hasChanges else { return }
  509. try onContext.save()
  510. debug(
  511. .coreData,
  512. "Saving to Core Data successful in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.succeeded)"
  513. )
  514. } catch let error as NSError {
  515. debug(
  516. .coreData,
  517. "Saving to Core Data failed in \(callingFunction) in \(callingClass): \(DebuggingIdentifiers.failed) with error \(error), \(error.userInfo)"
  518. )
  519. throw error
  520. }
  521. }
  522. }